Skip to content

fix(security): patch path traversal and symlink in plugin upload (#719) - #614

Open
tri2510 wants to merge 5 commits into
eclipse-autowrx:mainfrom
tri2510:security/plugin-upload-path-traversal
Open

fix(security): patch path traversal and symlink in plugin upload (#719)#614
tri2510 wants to merge 5 commits into
eclipse-autowrx:mainfrom
tri2510:security/plugin-upload-path-traversal

Conversation

@tri2510

@tri2510 tri2510 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes three security vulnerabilities in POST /v2/plugin/upload/:slug reported in issue #719.

Vulnerabilities Fixed

# Vulnerability CWE Fix
1 Path traversal via slug — slug was validated only as Joi.string().required(), allowing URL-encoded ../ sequences to extract the zip into arbitrary directories (e.g. backend/src/ → RCE on next process restart) CWE-22 Apply the existing slug custom validator (rejects non-slug characters) + add path.resolve containment check in the controller (defense in depth)
2 Symlink-based arbitrary file readspawn('unzip') recreated symbolic links, and express.static followed them, allowing read access to any file on disk (e.g. .env containing JWT_SECRET) CWE-59 Replace spawn('unzip') with yauzl-based safeExtractZip() that rejects symlink entries, absolute paths, and ../ in entry names. Add dotfiles: 'ignore' to express.static mounts for /plugin
3 Missing authorization — the admin checkPermission guard was commented out, and the ownership check ran after extraction had already completed CWE-862 Move ownership check before extraction so files are never written for unauthorized users. Remove commented-out checkPermission and redundant auth() from route (auth already applied via router.use(auth()) at line 26)

Files Changed

  • backend/src/validations/plugin.validation.js — apply slug custom validator to uploadInternal.params.slug
  • backend/src/controllers/plugin.controller.js — replace spawn('unzip') with safeExtractZip(), add path containment check, move ownership check before extraction
  • backend/src/routes/v2/system/plugin.route.js — remove commented-out checkPermission and redundant auth()
  • backend/src/app.js — add dotfiles: 'ignore' to express.static mounts for /plugin and /static/plugin
  • backend/package.json / backend/yarn.lock — add yauzl dependency

Test plan

  • Existing plugin upload flow still works (upload zip → extract → serve at /plugin/<slug>/index.js)
  • ../ in slug is rejected by validation (e.g. ..%2F..%2Fsrc → 400)
  • Zip containing symlink entries is rejected with 400
  • Upload to existing slug owned by another user returns 403 without extracting any files
  • yarn lint passes on changed files
  • Backend unit tests pass (no new failures vs. baseline)

Closes #719

tri2510 added 2 commits August 7, 2026 10:36
Fixes three vulnerabilities in POST /v2/plugin/upload/:slug:

1. Path traversal via slug (CWE-22): slug was validated only as
   Joi.string().required(), allowing URL-encoded ../ sequences to
   extract the zip into arbitrary directories (e.g. backend/src/ → RCE).
   - Apply the existing slug custom validator (rejects non-slug chars)
   - Add path.resolve containment check in the controller (defense in depth)

2. Symlink-based arbitrary file read (CWE-59): spawn('unzip') recreated
   symbolic links, and express.static followed them, allowing read
   access to any file (e.g. .env containing JWT_SECRET).
   - Replace spawn('unzip') with safe yauzl-based extraction that rejects
     symlink entries, absolute paths, and ../ in entry names
   - Add dotfiles: 'ignore' to express.static mounts for /plugin

3. Missing authorization (CWE-862): the admin checkPermission guard
   was commented out, and the ownership check ran after extraction.
   - Move ownership check before extraction so files are never written
     for unauthorized users
   - Remove commented-out checkPermission and redundant auth() from route
     (auth() already applied via router.use(auth()) at line 26)

@NhanLuongBGSV NhanLuongBGSV left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — fix(security): patch path traversal and symlink in plugin upload (#719)

Overall: Approve with minor comments. The three reported vulnerabilities are correctly and soundly addressed. The remaining issues are minor (cleanup, scope-creep, test coverage) and don't block merge.

The three fixes are correct

1. Path traversal via slug (CWE-22) — fixed ✅

  • Joi.string().required().custom(slug) is applied to uploadInternal.params.slug. slug is already imported in the validation file (custom.validation.js), and slugify(value) !== value rejects encoded ../ sequences after Express decodes them (..%2F..%2Fsrc../../srcslugify strips to src → mismatch → 400). The validator runs before upload.single('file') in the route, so a bad slug is rejected before multer writes a temp file.
  • Defense-in-depth path.resolve(pluginPath) containment check in the controller is valid: PLUGIN_DIR = path.join(__dirname, '../../static/plugin') is normalized by path.join (it resolves ..), so the startsWith(PLUGIN_DIR + path.sep) check behaves correctly — no false positive for legitimate slugs.

2. Symlink arbitrary file read (CWE-59) — fixed ✅

  • Replacing spawn('unzip') with manual yauzl extraction is the real fix: yauzl never recreates symlinks as actual symlinks — a symlink entry's content (the target path) would be written as a text file, which is harmless. The Unix-mode check (0o120000) is a correct additional guard. Path containment + absolute/.. rejection + dotfiles: 'ignore' on the express.static mounts close the read vector. Solid.

3. Missing authorization (CWE-862) — fixed ✅

  • Ownership check (getPluginBySlugcreated_by comparison) is moved before extraction, so no files are written for unauthorized users. Removing the redundant auth() is correct — router.use(auth()) at line 26 already authenticates this route. Removing the commented-out checkPermission changes nothing (it was already commented). The reused existing variable stays in scope for the later if (existing) upsert block. ✅

Issues worth addressing (none block merge)

A. No automated regression tests added. This is a security fix against issue #719, yet there are no existing or new tests for uploadInternalPlugin (only model-level tests exist). The test plan is manual-only. For security work, regression tests would be valuable — at minimum: (1) ../-encoded slug → 400, (2) zip with a symlink entry → 400, (3) upload to another user's slug → 403 and no files written to static/plugin/<slug>/. Recommend adding these.

B. Partial files + file-descriptor leak on rejection. When safeExtractZip rejects mid-archive, previously-written entries remain on disk in pluginPath, and the zipfile handle is never closed (autoClose only fires on end/close, which won't happen since we stop calling readEntry). Consider:

  • On reject, call zipfile.close() (or zipfile.destroy()) to release the fd.
  • Clean up pluginPath on error (the controller already has cleanup for the temp upload, but not for a half-extracted plugin dir) so a malicious/valid-then-malicious zip doesn't leave junk behind.

C. Over-broad .. substring check. entry.fileName.includes('..') rejects any entry with two consecutive dots, e.g. version-1..0.txt or my..notes.js. It's safe (over-strict, never under-strict), but a stricter path.normalize/segment-based check would avoid false positives on legitimate filenames. Acceptable trade-off, just noting.

D. Scope creep — dev-stage/.env.dev-stage.sample + .gitignore. These are unrelated to the security fix (#719) and bundle dev-stage environment setup into a security PR. The sample itself is fine (placeholders, not real secrets), but it muddies the security review and should ideally be a separate commit/PR.

E. Minor authorization TOCTOU (low). existing is fetched before extraction; the DB state could change between the ownership check and the later upsertPluginBySlug. Low risk, acceptable.

Cleanup notes

  • The trailing-whitespace / blank-line churn at the bottom of plugin.validation.js and the reflowed isAdmin ternaries onto single lines are cosmetic — fine, but they add diff noise.

Verdict: ship it, ideally with (A) regression tests and (B) error-path fd/cleanup added as follow-ups, and (D) split out into its own commit. The core security fixes are correct and well-reasoned.

Reviewed by Claude Code.

Add unit tests exercising the real production code from PR eclipse-autowrx#614:
- slug validation rejects path traversal (../, absolute, URL-encoded)
- safeExtractZip rejects path-traversal, absolute, and symlink entries,
  and does not escape the target dir or create symlinks on disk
- authorization gate (CWE-862) returns 403 without writing files or
  mutating the plugin record when the slug is owned by another user

Export safeExtractZip from the controller for testability (not used by
route handlers). Includes a Python helper to build malicious zip fixtures.

Co-Authored-By: Claude <noreply@anthropic.com>
@NhanLuongBGSV

Copy link
Copy Markdown
Contributor

Test note + a finding from running the fixes against real malicious zips

I added regression tests exercising the PR's real production code (commit 86c101c):

  • backend/tests/unit/controllers/plugin.upload.security.test.js — slug validation rejects ../, absolute, URL-encoded; safeExtractZip rejects path-traversal / absolute / symlink entries (asserting nothing escapes the target dir and no symlink is created on disk); a valid zip extracts correctly.
  • backend/tests/unit/controllers/plugin.upload.auth.test.js — the authorization gate returns 403 without writing files or calling upsertPluginBySlug when the slug is owned by another user (CWE-862 ordering).
  • backend/tests/fixtures/build_zip.py — helper to build malicious zip fixtures (regular/dir/symlink entries).

safeExtractZip is exported from the controller for testability (not used by route handlers). All 22 tests pass locally; no new lint errors on the changed files.

Finding while writing the tests: yauzl 3.x already rejects ../ and absolute-path entries itself (invalid relative path / absolute path) before this PR's explicit checks run. So the PR's ../ and absolute-path checks are defense-in-depth — they still matter for non-traversal filenames that merely contain .. (e.g. foo..bar.txt, which yauzl allows but the PR's includes('..') rejects). The symlink rejection is the genuinely net-new protection — yauzl allows symlink entries, and the PR's Unix-mode check (0o120000) is what blocks them. (Worth noting: even if the mode check missed a symlink, the manual yauzl extraction writes the link-target path as a text file, never as an actual symlink — so the original spawn('unzip')-based symlink recreation is fully gone.)

How to run:

cd backend
MONGODB_URL="mongodb://localhost:27017/autowrx-test" NODE_ENV=test JWT_SECRET=test-secret \
  npx jest tests/unit/controllers/plugin.upload.security.test.js tests/unit/controllers/plugin.upload.auth.test.js --forceExit

Applied as a maintainer push to this branch (thanks for enabling edits from maintainers).

@NhanLuongBGSV

Copy link
Copy Markdown
Contributor

Note on the license-headers-check failure: it's not from this PR's changes. actions/checkout on a cross-repo PR checks out the merge commit (refs/pull/614/merge = main-tip ⊕ this branch), so the git diff base...HEAD picks up main's own recently-merged files that lack the SPDX header (e.g. .agents/tests/*.ts from #609, backend/src/validations/modelTemplate.validation.js, several frontend/.../*.tsx/.ts). I confirmed those files are headerless on origin/main already. The 6 files actually changed by this PR (app.js, plugin.controller.js, plugin.route.js, plugin.validation.js, and the two new test files) all carry the header — the two test files I added include it. The check was green on the Aug 7 run because #609 hadn't merged yet.

So the red check is pre-existing repo header debt surfaced by the merge-commit checkout, not a regression from this branch. (Unrelated to the security fix — happy to add the missing headers to those main files in a separate PR if helpful, rather than bloating this security PR.)

NhanLuongBGSV and others added 2 commits August 13, 2026 06:53
Reverts the dev-stage/.env.dev-stage.sample + .gitignore change (commit
3d7aef4) — it is unrelated to the plugin-upload security fix (#719) and
should ship in its own PR.

Co-Authored-By: Claude <noreply@anthropic.com>
On rejection, safeExtractZip previously leaked the yauzl file descriptor
(autoClose only fires on 'end', which never happens after a rejected entry)
and left partially extracted files in the plugin directory. Repeated
malicious/corrupt uploads would accumulate orphan dirs and exhaust fds.

- Close the zipfile fd and destroy in-flight read/write streams on failure
- Remove any partially extracted content from targetDir on failure
  (transactional: full extract or no output)
- Always remove the multer temp upload in the caller via try/finally
  (previously skipped on extraction failure, leaking files under static/uploads)

Adds regression tests for the cleanup and that success keeps the target.

Co-Authored-By: Claude <noreply@anthropic.com>
@NhanLuongBGSV

Copy link
Copy Markdown
Contributor

Resolved review item B (safeExtractZip fd leak + partial-file cleanup) — pushed as commit 86002b0.

What changed:

  • fd leak fixed: on rejection, safeExtractZip now closes the yauzl zipfile and destroys any in-flight read/write streams (previously autoClose never fired because readEntry stopped, so the fd leaked on every failed upload).
  • partial-file cleanup / disk accumulation fixed: safeExtractZip is now transactional — on failure it removes any partially extracted content from the target dir (full extract or no output). The caller also removes the multer temp upload via try/finally (previously skipped on extraction failure, which leaked files under static/uploads).

Net effect: failed/corrupt/malicious uploads no longer leak file descriptors or leave orphan plugin dirs / temp files behind, so disk and fd usage stay flat over time.

Added regression tests: partial content is removed on rejection, and a successful extraction still keeps the target dir. All 24 plugin-upload regression tests pass locally.

Remaining optional follow-ups from the review: C (over-broad .. check) and E (TOCTOU).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants